¿Você está cansado das exceções de ponteiro nulo? Avalie a possibilidade de usar Optional do Java SE 8.
Por Raoul-Gabriel Urma,
Um homem muito sábio disse alguma vez que ninguém é um verdadeiro programador Java até não ter enfrentado uma exceção de ponteiro nulo. Brincadeiras à parte, a referência nula origina muitos problemas, pois, com frequência, é usada para identificar a ausência de um valor. O Java SE 8 apresenta uma nova classe chamada java.util.Optional que pode aliviar alguns desses problemas. Vamos começar com um exemplo para ver os perigos de se usar null. Vamos pensar, por exemplo, em uma estrutura de objetos aninhados para representar Computer, como ilustrado na Figura 1.
Figura 1: Estrutura aninhada para representar Computer
Quais os problemas que o seguinte código pode apresentar?
String version = computer.getSoundcard().getUSB().getVersion();
O código parece bastante lógico. No entanto, muitos computadores (por exemplo, o Raspberry Pi) são distribuídos sem placa de som. Então, qual é o resultado de getSoundcard()? Uma (má) prática habitual é devolver a referência nula para indicar a ausência de placa de som. Infelizmente, isso significa que a chamada a getUSB() tentará retornar a porta USB de uma referência nula; por conseguinte, será lançada uma exceção NullPointerException em tempo de execução e o programa deixará de rodar. Imagine que seu programa estivesse rodando no equipamento de um cliente: O que esse cliente diria se o programa, de repente, falhasse? Para oferecer um mínimo contexto histórico, Tony Hoare –um dos gigantes das ciências da computação– escreveu: "Eu chamo de meu erro do bilhão de dólares: a invenção da referência nula em 1965. Não consegui resistir à tentação de inserir uma referência nula. Era tão fácil de implementar...". O que pode ser feito para evitar as exceções de ponteiro nulo involuntárias? É possível adotar uma atitude defensiva e adicionar verificações para evitar as referências nulas, como pode se ver na Lista 1:
String version = "UNKNOWN";
if(computer != null){
Soundcard soundcard = computer.getSoundcard();
if(soundcard != null){
USB usb = soundcard.getUSB();
if(usb != null){
version = usb.getVersion();
}
}
}
Lista 1 Porém, é fácil ver que o código da Lista 1 rapidamente começa a ficar deselegante por causa das verificações aninhadas. Infelizmente, precisamos de muito código repetitivo para estar certos de não obter um erro NullPointerException. Além disso, é um pouco chato que essas verificações interfiram com a lógica de negócios. De fato, diminuem a legibilidade geral do programa. Ainda mais, trata-se de um processo passível de erros: o que aconteceria se você esquecesse de conferir que uma propriedade pode resultar nula? Neste artigo, vou argumentar que usar null para representar a ausência de um valor é uma abordagem errada. Precisamos de uma forma melhor de modelar a ausência e a presença de um valor. Para fornecer certo contexto à análise, vamos avaliar o que outras linguagens de programação oferecem.
Quais são as alternativas ao uso de null?
Linguagens como Groovy têm um operador de navegação segura representado por "?." para evitar sem riscos possíveis referências nulas. (Note-se que C#, em breve, incluirá também este operador, e que sua intenção era incluí-lo no Java SE 7, mas não foi possível fazê-lo nessa versão.) Funciona assim:
String version = computer?.getSoundcard()?.getUSB()?.getVersion();
Neste caso, a variável version receberá um valor null se computer for null ou getSoundcard() retornar null ou getUSB() retornar null. Não é necessário escrever condições aninhadas complexas para verificar a presença de null. Além disso, Groovy também tem o operador Elvis "?:" (se você olhar de lado, vai reconhecer o famoso penteado de Elvis), que pode ser utilizado para casos simples, quando um valor default for requerido. No exemplo a seguir, se a expressão usada pelo operador de navegação segura retornar null, o valor default "UNKNOWN" será retornado; caso contrário, será retornada a tag com a versão disponível.
String version =
computer?.getSoundcard()?.getUSB()?.getVersion() ?: "UNKNOWN";
Outras linguagens funcionais como Haskell e Scala, adotam uma visão diferente. Haskell inclui um tipo Maybe, que, basicamente, encapsula um valor opcional. Um valor do tipo Maybe pode conter um valor de um determinado tipo ou nada. Não existe o conceito de referência nula. Scala trabalha com um conceito similar, chamado Option[T], para encapsular a presença ou ausência de um valor do tipo T. Após isso, é necessário conferir de maneira explícita se um valor está presente ou ausente usando operações disponíveis no tipo Option, tornando obrigatória a "verificação de null". Não é mais possível "esquecer de verificar" pois o sistema de tipos não permite. Ok, temos nos desviado um pouco do assunto e tudo isso parece bastante abstrato. Talvez vocês estejam se perguntando: "Então, o que é que o Java SE 8 oferece?".
Optional em poucas palavras
O Java SE 8 inclui uma nova classe chamada java.util.Optional<T>, inspirada em Haskell e Scala. Trata-se de uma classe que encapsula um valor opcional, como mostrado na Lista 2, incluída a seguir, e na ///Figura 2. Optional pode se considerar um contêiner de valor único que, ou contém um valor ou não contém (nesse caso, diz-se que está "vazio"), conforme ilustrado na Figura 2.
Figura 2: Placa de som opcional
Podemos modificar nosso modelo e usar Optional, como pode se observar na Lista 2:
public class Computer {
private Optional<Soundcard> soundcard;
public Optional<Soundcard> getSoundcard() { ... }
...
}
public class Soundcard {
private Optional<USB> usb;
public Optional<USB> getUSB() { ... }
}
public class USB{
public String getVersion(){ ... }
}
Lista 2 Na Lista 2, fica logo evidente que um computador pode ou não ter placa de som (a placa de som é opcional). Além disso, a placa de som pode opcionalmente ter uma porta USB. Trata-se de uma melhoria em comparação ao modelo anterior, pois este novo modelo reflete claramente se um determinado valor pode faltar. Note-se que em bibliotecas como Guava possibilidades semelhantes estão disponíveis. Mas, o que é realmente possível fazer com um objeto Optional<Soundcard> ? Afinal, você deseja obter o número de versão da porta USB. Em poucas palavras, a classe Optional inclui métodos que permitem tratar explicitamente dos casos em que um valor está presente ou ausente. Porém, a vantagem em relação a referências nulas é que a classe Optional obriga a pensar quando o valor não estiver presente. Como consequência disso, você pode evitar exceções de ponteiro nulo indesejadas? É importante salientar que o objetivo da classe Optional não é substituir todas as referências nulas, mas seu objetivo é ajudar a projetar APIs mais compreensíveis, de modo que só lendo a assinatura de um método seja possível saber se pode se esperar o retorno de um valor opcional. Nesse caso, seremos obrigados a "desencapsular" a classe Optional para lidar com a ausência de um valor.
Padrões para a adoção de Optional
Chega de conversa: vamos passar para o código. Em primeiro lugar, vamos ver como reescrever padrões típicos de verificação de null utilizando Optional. Até o final deste artigo, você vai saber como usar Optional para reescrever o código mostrado na Lista 1, na qual eram realizadas diversas verificações de null aninhadas (ver abaixo):
String name = computer.flatMap(Computer::getSoundcard)
.flatMap(Soundcard::getUSB)
.map(USB::getVersion)
.orElse("UNKNOWN");
Nota: Certifique-se de revisar antes a sintaxe das referências a métodos e expressões lambda do Java SE 8 (ver "Java 8: Lambdas") bem como os conceitos de canalização (pipelining) de streams (ver "Processing Data with Java SE 8 Streams" [Processamento de dados com streams do Java SE 8]).
Criação de objetos Optional
Em primeiro lugar, como criar objetos Optional? Há diversas formas: Este é um Optional vazio:
Optional<Soundcard> sc = Optional.empty();
E este é um Optional com um valor não nulo:
SoundCard soundcard = new Soundcard(); Optional<Soundcard> sc = Optional.of(soundcard);
Se soundcard fosse nulo, imediatamente seria lançada uma exceção NullPointerException (em lugar de obter um erro latente quando você tentar acessar as propriedades de soundcard). Utilizando ofNullable, também é possível criar um objeto Optional que pode conter um valor nulo:
Optional<Soundcard> sc = Optional.ofNullable(soundcard);
Se soundcard fosse nulo, o objeto Optional resultante estaria vazio.
Fazer alguma coisa se um valor estiver presente
Agora que temos um objeto Optional, podemos usar os métodos disponíveis para tratar de maneira explícita da presença ou ausência de valores. Em vez de ter que lembrar de fazer uma verificação de null, como se segue:
SoundCard soundcard = ...;
if(soundcard != null){
System.out.println(soundcard);
}
podemos usar o método ifPresent() como se segue:
Optional<Soundcard> soundcard = ...;
soundcard.ifPresent(System.out::println);
Não precisamos mais realizar uma verificação de null explícita: o próprio sistema de tipos vai se ocupar de fazer. Se o objeto Optional estivesse vazio, nada seria impresso. Também podemos utilizar o método isPresent() para saber se há um valor em um objeto Optional. Além disso, há um método get() que retorna o valor contido no objeto Optional, caso esteja presente. Caso contrário, lança uma exceção NoSuchElementException. É possível combinar ambos os métodos para evitar exceções, como mostrado a seguir:
if(soundcard.isPresent()){
System.out.println(soundcard.get());
}
No entanto, este não é o uso recomendado de Optional (não representa uma melhoria significativa em relação às verificações de null aninhadas); além disso, há alternativas mais idiomáticas, que exploraremos abaixo.
Valores default e ações
Um padrão típico consiste em retornar um valor default caso se determine que o resultado de uma operação é nulo. Em geral, para atingir esse objetivo pode se usar o operador ternário:
Soundcard soundcard =
maybeSoundcard != null ? maybeSoundcard
: new Soundcard("basic_sound_card");
Usando um objeto Optional, é possível reescrever o código anterior utilizando o método orElse(), que fornece um valor default caso Optional esteja vazio:
Soundcard soundcard = maybeSoundcard.orElse(new Soundcard("defaut"));
De modo similar, pode se usar o método orElseThrow(), que, em vez de fornecer um valor default caso Optional esteja vazio, lança uma exceção:
Soundcard soundcard =
maybeSoundCard.orElseThrow(IllegalStateException::new);
Rejeitando certos valores com o método filter
Com frequência, é necessário chamar um método de um objeto e verificar alguma propriedade. Por exemplo, pode ser necessário verificar se a porta USB é de uma determinada versão. Para fazer isso sem riscos, é necessário primeiramente verificar se a referência que aponta para um objeto USB é nula e depois chamar o método getVersion(), como se segue:
USB usb = ...;
if(usb != null && "3.0".equals(usb.getVersion())){
System.out.println("ok");
}
É possível reescrever este padrão utilizando o método filter para um objeto Optional, como se segue:
Optional<USB> maybeUSB = ...;
maybeUSB.filter(usb -> "3.0".equals(usb.getVersion())
.ifPresent(() -> System.out.println("ok"));
O método filter toma um predicado como argumento. Se houver um valor no objeto Optional e esse valor atender ao predicado, o método filter retorna esse valor; caso contrário, retorna um objeto Optional vazio. Você pode ter encontrado um padrão similar caso tenha utilizado o método filter com a interface Stream.
Extração e transformação de valores usando o método map
Outro padrão frequente consiste na extração de informações de um objeto. Por exemplo, você pode querer extrair o objeto USB de um objeto Soundcard e verificar, na sequência, se ele pertence à versão certa. O código típico seria:
if(soundcard != null){
USB usb = soundcard.getUSB();
if(usb != null && "3.0".equals(usb.getVersion()){
System.out.println("ok");
}
}
É possível reescrever este padrão de "verificar null e extrair" (neste caso, o objeto Soundcard) usando o método map.
Optional<USB> usb = maybeSoundcard.map(Soundcard::getUSB);
Existe um paralelo direto com o método map usado com streams. Nele, se passa uma função para o método map, que aplica essa função em cada elemento de um stream. No entanto, se o stream estiver vazio nada vai ocorrer. O método map da classe Optional faz o mesmo: a função passada como argumento (neste caso, uma referência a um método para extrair a porta USB) "transforma" o valor contido em Optional, enquanto nada acontece se Optional estiver vazio. Por último, podemos combinar o método map com o método filter para rejeitar uma porta USB cuja versão não for 3.0:
maybeSoundcard.map(Soundcard::getUSB)
.filter(usb -> "3.0".equals(usb.getVersion())
.ifPresent(() -> System.out.println("ok"));
Fantástico: nosso código começa a se aproximar do objetivo desejado, sem verificações de null explícitas que barrem nosso caminho.
Cascata de objetos Optional com o método flatMap
Já vimos alguns padrões que podem ser reformulados para usar Optional. Agora, como podemos escrever o seguinte código de modo seguro?
String version = computer.getSoundcard().getUSB().getVersion();
Note-se que a única função deste código é extrair um objeto de outro, exatamente o objetivo do método map. No início do artigo, modificamos nosso modelo de modo que Computer tivesse Optional<Soundcard> e Soundcard tivesse Optional<USB>, portanto deveria ser possível escrever:
String version = computer.map(Computer::getSoundcard)
.map(Soundcard::getUSB)
.map(USB::getVersion)
.orElse("UNKNOWN");
Infelizmente, não é possível compilar este código. Por quê? A variável Computer é de tipo Optional<Computer>, portanto é correto chamar o método map. No entanto, getSoundcard() retorna um objeto de tipo Optional<Soundcard>, significando que o resultado da operação map é um objeto de tipo Optional<Optional<Soundcard>>. Por conseguinte, a chamada a getUSB() não é válida porque o Optional exterior contém como valor outro Optional que, é claro, não suporta o método getUSB(). A Figura 3 mostra a estrutura aninhada de Optional que seria obtida.
Figura 3: Um Optional de dois níveis
Então, como podemos resolver esse problema? Mais uma vez, podemos recorrer a um padrão que talvez você tenha usado antes com streams: o método flatMap. Com os streams, o método flatMap toma uma função como argumento, retornando um novo stream. Essa função é aplicada a cada elemento do stream, o que teria como resultado um stream de streams. No entanto, o efeito de flatMap consiste em substituir cada stream gerado pelo conteúdo desse stream. Em outras palavras, todos os streams gerados pela função se amalgamam ou "achatam" em um único stream. O que precisamos neste caso é uma coisa similar, mas procuramos "achatar" um Optional de dois níveis e obter, em seu lugar, um único nível. Bem, temos boas novas: Optional também suporta um método flatMap. Seu objetivo é aplicar a função de transformação ao valor de um Optional (como acontece com a operação map) e, a seguir, "achatar" o Optional de dois níveis para obter um único nível. A Figura 4 mostra a diferença entre map e flatMap quando a função de transformação retorna um objeto Optional.
Figura 4: Comparação do uso de map e flatMap com Optional
Então, para o código ser correto, temos que reescrevê-lo da seguinte maneira usando flatMap:
String version = computer.flatMap(Computer::getSoundcard)
.flatMap(Soundcard::getUSB)
.map(USB::getVersion)
.orElse("UNKNOWN");
O primeiro flatMap garante que Optional<Soundcard> seja retornado em vez de Optional<Optional<Soundcard>>, e o segundo flatMap atinge o mesmo objetivo com o retorno de Optional<USB>. Note-se que no caso da terceira chamada, só é necessário map() porque getVersion() retorna uma String em vez de um objeto Optional. Ótimo! Temos avançado muitíssimo: passamos de escrever penosas verificações de null aninhadas para escrever um código declarativo legível, combinável e mais bem protegido das exceções de ponteiro nulo.
Conclusão
Neste artigo, tratamos da adoção da nova classe java.util.Optional<T> do Java SE 8. O objetivo de Optional não é substituir todas as referências nulas do código, mas ajudar a projetar melhores APIs nas quais, mediante a leitura da assinatura de um método, os usuários possam saber se têm que esperar um valor opcional. Além disso, Optional obriga a "desencapsular" um Optional a fim de lidar com a ausência de um valor; como resultado, é possível proteger o código de inesperadas exceções de ponteiro nulo.
Informações adicionais
- Capítulo 9, "Optional: a better alternative to null" (Optional, uma alternativa melhor que null), em Java 8 in Action: Lambdas, Streams, and Functional-style Programming (Java 8 em ação: lambdas, streams e programação funcional)
- "Monadic Java" (Java monádico) por Mario Fusco
- "Processing Data with Java SE 8 Streams" (Processamento de dados com streams do Java SE 8)
Agradecimentos
Agradeço a Alan Mycroft e Mario Fusco por terem empreendido a aventura de escrever Java 8 in Action: Lambdas, Streams, and Functional-style Programming junto comigo.
Raoul-Gabriel Urma (@raoulUK) é doutorando em Ciências da Computação na Universidade de Cambridge, onde desenvolve sua pesquisa em linguagens de programação. Ele é coautor de Java 8 in Action: Lambdas, Streams, and Functional-style Programming, que será publicado em breve pela Manning. Também participa habitualmente como palestrante em conferências sobre Java de primeira linha (por exemplo, Devoxx e Fosdem) e se desempenha como instrutor. Além disso, trabalhou em diversas empresas prestigiosas, entre elas, na equipe Python da Google, no grupo Java Platform da Oracle, eBay e Goldman Sachs, bem como em diversos projetos de novos empreendimentos.